#  Copyright 2008-2015 Nokia Networks
#  Copyright 2016-     Robot Framework Foundation
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

import os
import sys
import inspect

from robot.errors import DataError

from .encoding import system_decode, system_encode
from .error import get_error_details
from .platform import JYTHON, IRONPYTHON, PY2, PY3, PYPY
from .robotpath import abspath, normpath
from .robottypes import type_name, is_unicode

if PY3:
    from importlib import invalidate_caches as invalidate_import_caches
else:
    invalidate_import_caches = lambda: None
if JYTHON:
    from java.lang.System import getProperty


class Importer(object):

    def __init__(self, type=None, logger=None):
        if not logger:
            from robot.output import LOGGER as logger
        self._type = type or ''
        self._logger = logger
        self._importers = (ByPathImporter(logger),
                           NonDottedImporter(logger),
                           DottedImporter(logger))
        self._by_path_importer = self._importers[0]

    def import_class_or_module(self, name, instantiate_with_args=None,
                               return_source=False):
        """Imports Python class/module or Java class with given name.

        Class can either live in a module/package or be standalone Java class.
        In the former case the name is something like 'MyClass' and in the
        latter it could be 'your.package.YourLibrary'. Python classes always
        live in a module, but if the module name is exactly same as the class
        name then simple 'MyLibrary' will import a class.

        Python modules can be imported both using format 'MyModule' and
        'mymodule.submodule'.

        `name` can also be a path to the imported file/directory. In that case
        importing is done using `import_class_or_module_by_path` method.

        If `instantiate_with_args` is not None, imported classes are
        instantiated with the specified arguments automatically.
        """
        try:
            imported, source = self._import_class_or_module(name)
            self._log_import_succeeded(imported, name, source)
            imported = self._instantiate_if_needed(imported, instantiate_with_args)
        except DataError as err:
            self._raise_import_failed(name, err)
        else:
            return self._handle_return_values(imported, source, return_source)

    def _import_class_or_module(self, name):
        for importer in self._importers:
            if importer.handles(name):
                return importer.import_(name)

    def _handle_return_values(self, imported, source, return_source=False):
        if not return_source:
            return imported
        if source and os.path.exists(source):
            source = self._sanitize_source(source)
        return imported, source

    def _sanitize_source(self, source):
        source = normpath(source)
        if os.path.isdir(source):
            candidate = os.path.join(source, '__init__.py')
        elif source.endswith('.pyc'):
            candidate = source[:-4] + '.py'
        elif source.endswith('$py.class'):
            candidate = source[:-9] + '.py'
        elif source.endswith('.class'):
            candidate = source[:-6] + '.java'
        else:
            return source
        return candidate if os.path.exists(candidate) else source

    def import_class_or_module_by_path(self, path, instantiate_with_args=None):
        """Import a Python module or Java class using a file system path.

        When importing a Python file, the path must end with '.py' and the
        actual file must also exist. When importing Java classes, the path
        must end with '.java' or '.class'. The class file must exist in both
        cases and in the former case also the source file must exist.

        If `instantiate_with_args` is not None, imported classes are
        instantiated with the specified arguments automatically.
        """
        try:
            imported, source = self._by_path_importer.import_(path)
            self._log_import_succeeded(imported, imported.__name__, source)
            return self._instantiate_if_needed(imported, instantiate_with_args)
        except DataError as err:
            self._raise_import_failed(path, err)

    def _raise_import_failed(self, name, error):
        import_type = '%s ' % self._type if self._type else ''
        msg = "Importing %s'%s' failed: %s" % (import_type, name, error.message)
        if not error.details:
            raise DataError(msg)
        msg = [msg, error.details]
        msg.extend(self._get_items_in('PYTHONPATH', sys.path))
        if JYTHON:
            classpath = getProperty('java.class.path').split(os.path.pathsep)
            msg.extend(self._get_items_in('CLASSPATH', classpath))
        raise DataError('\n'.join(msg))

    def _get_items_in(self, type, items):
        yield '%s:' % type
        for item in items:
            if item:
                yield '  %s' % (item if is_unicode(item)
                                else system_decode(item))

    def _instantiate_if_needed(self, imported, args):
        if args is None:
            return imported
        if inspect.isclass(imported):
            return self._instantiate_class(imported, args)
        if args:
            raise DataError("Modules do not take arguments.")
        return imported

    def _instantiate_class(self, imported, args):
        try:
            return imported(*args)
        except:
            raise DataError('Creating instance failed: %s\n%s' % get_error_details())

    def _log_import_succeeded(self, item, name, source):
        import_type = '%s ' % self._type if self._type else ''
        item_type = 'module' if inspect.ismodule(item) else 'class'
        location = ("'%s'" % source) if source else 'unknown location'
        self._logger.info("Imported %s%s '%s' from %s."
                          % (import_type, item_type, name, location))


class _Importer(object):

    def __init__(self, logger):
        self._logger = logger

    def _import(self, name, fromlist=None, retry=True):
        if name in sys.builtin_module_names:
            raise DataError('Cannot import custom module with same name as '
                            'Python built-in module.')
        invalidate_import_caches()
        try:
            try:
                return __import__(name, fromlist=fromlist)
            except ImportError:
                # Hack to support standalone Jython. For more information, see:
                # https://github.com/robotframework/robotframework/issues/515
                # http://bugs.jython.org/issue1778514
                if JYTHON and fromlist and retry:
                    __import__('%s.%s' % (name, fromlist[0]))
                    return self._import(name, fromlist, retry=False)
                # IronPython loses traceback when using plain raise.
                # https://github.com/IronLanguages/main/issues/989
                if IRONPYTHON:
                    exec('raise sys.exc_type, sys.exc_value, sys.exc_traceback')
                raise
        except:
            raise DataError(*get_error_details())

    def _verify_type(self, imported):
        if inspect.isclass(imported) or inspect.ismodule(imported):
            return imported
        raise DataError('Expected class or module, got %s.'
                        % type_name(imported))

    def _get_class_from_module(self, module, name=None):
        klass = getattr(module, name or module.__name__, None)
        return klass if inspect.isclass(klass) else None

    def _get_source(self, imported):
        try:
            source = inspect.getfile(imported)
        except TypeError:
            return None
        return abspath(source) if source else None


class ByPathImporter(_Importer):
    _valid_import_extensions = ('.py', '.java', '.class', '')

    def handles(self, path):
        return os.path.isabs(path)

    def import_(self, path):
        self._verify_import_path(path)
        self._remove_wrong_module_from_sys_modules(path)
        module = self._import_by_path(path)
        imported = self._get_class_from_module(module) or module
        return self._verify_type(imported), path

    def _verify_import_path(self, path):
        if not os.path.exists(path):
            raise DataError('File or directory does not exist.')
        if not os.path.isabs(path):
            raise DataError('Import path must be absolute.')
        if not os.path.splitext(path)[1] in self._valid_import_extensions:
            raise DataError('Not a valid file or directory to import.')

    def _remove_wrong_module_from_sys_modules(self, path):
        importing_from, name = self._split_path_to_module(path)
        importing_package = os.path.splitext(path)[1] == ''
        if self._wrong_module_imported(name, importing_from, importing_package):
            del sys.modules[name]
            self._logger.info("Removed module '%s' from sys.modules to import "
                              "fresh module." % name)

    def _split_path_to_module(self, path):
        module_dir, module_file = os.path.split(abspath(path))
        module_name = os.path.splitext(module_file)[0]
        if module_name.endswith('$py'):
            module_name = module_name[:-3]
        return module_dir, module_name

    def _wrong_module_imported(self, name, importing_from, importing_package):
        if name not in sys.modules:
            return False
        source = getattr(sys.modules[name], '__file__', None)
        if not source:  # play safe (occurs at least with java based modules)
            return True
        imported_from, imported_package = self._get_import_information(source)
        return (normpath(importing_from, case_normalize=True) !=
                normpath(imported_from, case_normalize=True) or
                importing_package != imported_package)

    def _get_import_information(self, source):
        imported_from, imported_file = self._split_path_to_module(source)
        imported_package = imported_file == '__init__'
        if imported_package:
            imported_from = os.path.dirname(imported_from)
        return imported_from, imported_package

    def _import_by_path(self, path):
        module_dir, module_name = self._split_path_to_module(path)
        # Other interpreters work also with Unicode paths.
        # https://bitbucket.org/pypy/pypy/issues/3112
        if PYPY and PY2:
            module_dir = system_encode(module_dir)
        sys.path.insert(0, module_dir)
        try:
            return self._import(module_name)
        finally:
            sys.path.remove(module_dir)


class NonDottedImporter(_Importer):

    def handles(self, name):
        return '.' not in name

    def import_(self, name):
        module = self._import(name)
        imported = self._get_class_from_module(module) or module
        return self._verify_type(imported), self._get_source(imported)


class DottedImporter(_Importer):

    def handles(self, name):
        return '.' in name

    def import_(self, name):
        parent_name, lib_name = name.rsplit('.', 1)
        parent = self._import(parent_name, fromlist=[str(lib_name)])
        try:
            imported = getattr(parent, lib_name)
        except AttributeError:
            raise DataError("Module '%s' does not contain '%s'."
                            % (parent_name, lib_name))
        imported = self._get_class_from_module(imported, lib_name) or imported
        return self._verify_type(imported), self._get_source(imported)
